Skip to content

raidz: fix space accounting after expansion - #18324

Open
Skountz wants to merge 1 commit into
openzfs:masterfrom
Skountz:fix/raidz-expansion-space-accounting
Open

raidz: fix space accounting after expansion#18324
Skountz wants to merge 1 commit into
openzfs:masterfrom
Skountz:fix/raidz-expansion-space-accounting

Conversation

@Skountz

@Skountz Skountz commented Mar 14, 2026

Copy link
Copy Markdown

Motivation and Context

After RAIDZ expansion, usable capacity is severely underreported because the deflation ratio (which converts raw
physical space to logical usable space) remains stuck at the original pre-expansion geometry. Both zfs list -o available and zpool list -o usable,available show values based on the old, less efficient parity layout.

Fixes #17784
Fixes #18199

Description

vdev_set_deflate_ratio() hard-codes txg 0 to compute the ratio. After expansion, new writes use a more efficient
geometry but the accounting never reflects this.

Fix in three parts:

  1. Add vdev_deflate_ratio_current (computed with UINT64_MAX) alongside the existing vdev_deflate_ratio (txg0). Apply a capacity correction in spa_update_dspace() (for zfs list available space, quota enforcement, and space reservations) and in spa_prop_get_config() (for ZPOOL_PROP_USABLE / ZPOOL_PROP_AVAILABLE), using the difference between the two ratios on the free portion of each expanded vdev. Only the free portion is corrected because dd_used_bytes tracks old-geometry blocks at the legacy ratio; correcting allocated space would inflate available space beyond what can physically be written, which could cause pool suspension on a full pool. As old blocks are rewritten and vs_alloc shrinks, the correction automatically grows, converging to the full geometry correction after a complete rewrite.
    The original txg-0 ratio is preserved in vdev_deflated_space() for self-consistent metaslab accounting and persistent DN_USED_BYTES tracking.
    Note: zpool list default columns (SIZE, ALLOC, FREE) show raw physical space and are not deflation-adjusted; this is by design and unchanged.

  2. Add vdev_get_deflate_ratio(vd, birth_txg) for per-block deflation using BP_GET_PHYSICAL_BIRTH(). Pass birth txg through dva_get_dsize_sync() and bp_get_dsize_sync(). Reuses existing reflow_node_t AVL tree infrastructure.

  3. Add the raidz_expansion_accounting feature flag (depends on enabled_txg). Records the txg at which per-block deflation ratio tracking was enabled. Blocks born before that txg use the legacy fixed ratio (matching how they were originally accounted); blocks born at or after use the per-birth-txg ratio. Follows the standard OpenZFS feature lifecycle: the user enables it via zpool upgrade or zpool set, and it is activated on the next RAIDZ expansion via spa_feature_incr() in vdev_raidz_attach_sync(). If enabled on an already-expanded pool, it activates immediately in feature_enable_sync() (following the SPA_FEATURE_HEAD_ERRLOG precedent). Prevents born/free accounting mismatches for blocks written between a pre-patch expansion and the first expansion with this code.

Self-consistency is preserved: metaslab accounting uses the original ratio (alloc/free always balance), capacity
reporting is corrected at the spa level, and per-block accounting uses stable birth-txg ratios. No negative overflows
or positive leaks.

How Has This Been Tested?

  • Full build on Ubuntu 24.04, make checkstyle clean.
  • All existing raidz_expand tests (001-007) pass.
  • Added five new ZTS tests:
    • raidz_expand_008_pos: Creates RAIDZ2-3 (1 data + 2 parity), writes 300MB, expands to 4 disks, verifies usable capacity increase (~100%) exceeds the raw 33% disk addition (confirming the deflation ratio correction is applied to ZPOOL_PROP_USABLE), verifies zfs dataset available also reflects the correction (exercises the spa_update_dspace() code path), checks alloc+free≈size consistency, verifies all accounting survives an export/import cycle, and verifies the USABLE property is not over-corrected (gap between USABLE and the geometric maximum must be proportional to fill level, catching any correction applied to allocated space instead of only free space).
    • raidz_expand_009_pos: Multiple sequential expansions (RAIDZ1 3→4→5→6 disks) with writes interspersed between each. Verifies that consumed space matches write size, free space decrease tracks consumed increase, and alloc+free≈size holds at every step.
    • raidz_expand_010_pos: Rewrite-between-expansions test (RAIDZ1 3→4→5). Deletes and rewrites all data after each expansion to force blocks from old geometry to new. Then fills the pool to capacity and verifies that total data written matches zfs available (deflated) within 30% tolerance.
    • raidz_expand_011_pos: Snapshot accounting across expansions. Takes snapshots before/between two expansions, deletes files from the active dataset, and verifies that snapshot referenced/used sizes remain stable and consistent. Also verifies pool-level accounting holds after snapshot destruction.
    • raidz_expand_012_pos: Feature flag lifecycle and born/free leak detection. Verifies raidz_expansion_accounting is enabled on new pools, activates on expansion, and survives export/import. Tests that deleting data written under both old and new geometries returns both pool-level allocated and per-dataset referenced to near-zero (no born/free accounting leak via bp_get_dsize_sync). Also tests second expansion, stress write/delete cycles, and the post-expansion enablement path (feature disabled at pool creation, expansion, then enablement triggers immediate activation).
  • Tested on Debian 13 VM with ZFS kernel modules loaded.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

Note: spa_feature_t enum changed (new SPA_FEATURE_RAIDZ_EXPANSION_ACCOUNTING), which updates spa_feature_table size and the libzfs.abi/libzfs_core.abi files accordingly. dva_get_dsize_sync() signature changed and vdev_get_deflate_ratio() was added, but these are kernel-internal symbols (EXPORT_SYMBOL) only.

Checklist:

  • My code follows the OpenZFS code style requirements.
  • I have read the contributing document.
  • I have added tests to cover my changes.
  • I have run the ZFS Test Suite with this change applied.
  • All commit messages are properly formatted and contain Signed-off-by.

@Skountz
Skountz force-pushed the fix/raidz-expansion-space-accounting branch 5 times, most recently from efca6c5 to 2b9d11e Compare March 14, 2026 19:08
@Skountz

Skountz commented Mar 14, 2026

Copy link
Copy Markdown
Author

@amotin — following your feedback on #18170, I've implemented the per-block birth txg approach you described, using BP_GET_PHYSICAL_BIRTH() and the existing reflow_node_t AVL tree. Metaslab self-consistency is preserved.
Would appreciate your thoughts!

@owlshrimp

Copy link
Copy Markdown

Maybe it would be worthwhile to add some explicit tests of this functionality, to verify that the amount of data required to fill a pool is approximately equal to the remaining freespace that is reported?

For example, create a pool and then do a series of large writes interspersed with raidz expansions, while verifying that the size of the write, the amount of freespace remaining, and the amount of space consumed before/after are all within some tolerance of each other? It might also be worthwhile doing a version with where you do a ZFS rewrite each time inbetween expansions.

@Skountz
Skountz force-pushed the fix/raidz-expansion-space-accounting branch from 2b9d11e to e7b3074 Compare March 15, 2026 16:06
@Skountz

Skountz commented Mar 15, 2026

Copy link
Copy Markdown
Author

@owlshrimp Good idea — added raidz_expand_009_pos (multi-expansion with writes between each step) and raidz_expand_010_pos (rewrites between expansions + fills pool to verify freespace accuracy).
All passing locally!

@owlshrimp

Copy link
Copy Markdown

...verifying that the size of the write, the amount of freespace remaining, and the amount of space consumed before/after are all within some tolerance of each other?

I suppose it would be more correct to say that the reported freespace before vs after should be approximately equal to the size of the write (with compression turned off, anyway). The various cases of "how much space should this have taken" with different stripe sizes makes my head hurt though, especially if you delete old data. Deleting old data is probably another area where tests verifying expected behavior would be useful.

@Skountz

Skountz commented Mar 15, 2026

Copy link
Copy Markdown
Author

Mh, the two new tests cover both.
Test 009 does multi-expansion with writes at each step, checking that freespace decrease tracks allocated increase (not the logical write size directly — on RAIDZ those differ due to parity).

Test 010 does delete+rewrite cycles between expansions, then fills the pool to verify reported freespace actually matches writable capacity.

Let me know if you think that covers it or if you had something else in mind.

@owlshrimp

owlshrimp commented Mar 15, 2026

Copy link
Copy Markdown

Let me know if you think that covers it or if you had something else in mind.

Reading through the testing strategies, I think this probably covers everything I can think of offhand. Maybe there are weird edgecases with mixes of pre and post expansion data, but 09 seems like at least a very good sanity check.

What would we expect to happen to the total figures if some quantity of pre-expansion data is deleted? I presume the freespace would go up by a bit more than the amount of data deleted, due to the difference in parity ratio?

@Skountz

Skountz commented Mar 15, 2026

Copy link
Copy Markdown
Author

What would we expect to happen to the total figures if some quantity of pre-expansion data is deleted? I presume the freespace would go up by a bit more than the amount of data deleted, due to the difference in parity ratio?

Right, pre-expansion blocks use more raw space per logical byte (worse parity ratio), so deleting them frees raw capacity that under the new geometry maps to more usable space. That's the convergence the commit message describes: as old blocks get deleted or rewritten, accounting gradually aligns with the new geometry.
Test 010 exercises exactly that path.

@owlshrimp

Copy link
Copy Markdown

I suppose every time you do an expansion (with these changes) the pre-expansion blocks belonging to files and snapshots look like they consume slightly more usable space than expected for their logical size.

Have you double checked things like the amount of space reported as consumed by old snapshots whose files have been deleted from the current view makes sense in some way? (eg. in zfs list -t all) Just thinking of potential edge cases.

@Skountz

Skountz commented Mar 15, 2026

Copy link
Copy Markdown
Author

Yes, pre-expansion blocks genuinely cost more raw space per logical byte (worse parity ratio), so that's expected and correct.
The accounting stays self-consistent because bp_get_dsize_sync() always uses the block's physical birth txg to look up the deflation ratio, so a given block always produces the same dsize regardless of when you compute it.

Good call on snapshots though, I don't have explicit test coverage for that. I'll add one just after dinner (UTC+1 here!)

@Skountz
Skountz force-pushed the fix/raidz-expansion-space-accounting branch from e7b3074 to 70330f7 Compare March 15, 2026 21:07
@Skountz

Skountz commented Mar 15, 2026

Copy link
Copy Markdown
Author

Also added an export/import check to test 008 — vdev_deflate_ratio_current isn't persisted on disk, it's recomputed during vdev_open() on import. So if that recomputation ever regressed, all accounting would silently break after a reboot or pool reimport. Now verified it survives the cycle.

@owlshrimp

Copy link
Copy Markdown

I guess every time you delete a pre-expansion file that exists in a snapshot, the snapshot grows by the same slightly-more-than-logical amount of raw size as the file used to occupy?

@Skountz
Skountz force-pushed the fix/raidz-expansion-space-accounting branch from 70330f7 to c5f8bf9 Compare March 15, 2026 21:31
@Skountz

Skountz commented Mar 15, 2026

Copy link
Copy Markdown
Author

Yes exactly, the snapshot's used increases by the dsize of those blocks, which uses the old deflation ratio (via birth txg). So it reflects the actual raw cost of holding pre-expansion data. And test 11 already verifies this.

@owlshrimp

Copy link
Copy Markdown

Assuming one rewrote all the data (and didn't keep any snapshots) are there any lingering differences between an expanded array and one that was constructed natively, given this patchset?

@Skountz

Skountz commented Mar 15, 2026

Copy link
Copy Markdown
Author

No, from this patchset's perspective there are no lingering differences. Once all data is rewritten under the new geometry, the per-block deflation ratios all reflect the current layout (identical to native), and the SPA-level capacity correction produces the same reported size as a natively constructed array.

The expansion feature itself does leave persistent metadata on disk (e.g., the raidz_expansion feature flag, expansion history), but that is unrelated to this patchset and does not affect space accounting or I/O behavior.

@Skountz
Skountz force-pushed the fix/raidz-expansion-space-accounting branch 2 times, most recently from 68c0a4d to 0ecea7f Compare March 16, 2026 13:12
@Skountz

Skountz commented Mar 16, 2026

Copy link
Copy Markdown
Author

This also follows up on the "time dependent deflate ratio" concept originally described by @ahrens in #12225 (comment)

Comment thread module/zfs/ddt_stats.c
Comment thread module/zfs/spa.c Outdated
Comment thread module/zfs/spa_misc.c Outdated
Comment thread module/zfs/vdev.c
Comment thread module/zfs/spa_misc.c
@Skountz
Skountz force-pushed the fix/raidz-expansion-space-accounting branch 2 times, most recently from 73cbd85 to 28b6f95 Compare March 16, 2026 23:14
Comment thread module/zfs/spa_misc.c Outdated
@Skountz
Skountz force-pushed the fix/raidz-expansion-space-accounting branch 2 times, most recently from 5f6c338 to 24a2224 Compare April 9, 2026 19:16
@NJSilverbird

Copy link
Copy Markdown

How do I apply this patch? Or is there a big chance this will find its way into an official openzfs release soon?

@owlshrimp

owlshrimp commented Apr 14, 2026

Copy link
Copy Markdown

How do I apply this patch? Or is there a big chance this will find its way into an official openzfs release soon?

This patch is not ready yet and is still work in progress. If you attempt to apply it you are putting your data at risk.

After it's complete and passes review it will be merged into the main branch, and presumably will ship in a new release soon after that.

@Skountz
Skountz force-pushed the fix/raidz-expansion-space-accounting branch from 24a2224 to 2b04764 Compare May 4, 2026 14:46
@Skountz

Skountz commented May 5, 2026

Copy link
Copy Markdown
Author

@amotin, @behlendorf

Hey, just wanted to follow up. I rebased and repushed yesterday so it should be clean on top of current main.
All review comments have been addressed as well so far.
Let me know if there's anything I can do to help move it forward!

@savely-krasovsky

Copy link
Copy Markdown

I am not a ZFS developer, but it makes sense not to squash commits during the review phase at least, since it's hard to understand what changed in the last commit.

@Skountz

Skountz commented May 5, 2026

Copy link
Copy Markdown
Author

I am not a ZFS developer, but it makes sense not to squash commits during the review phase at least, since it's hard to understand what changed in the last commit.

The contributing guidelines seems to disagree with you:

* Please attempt to limit pull requests to a single commit which resolves

But I agree that it can complicate things when the change spans multiple independent concerns. Here, the fix and its test are a single logical unit (and there was actually nothing to squash), so a single commit is both the rule and the right call. That said, it is still possible to review the changes file-by-file if looking at everything at once seems overwhelming.

@owlshrimp

Copy link
Copy Markdown

@amotin @behlendorf @ahrens This seems like a really useful series and a final missing piece for raidz expansion. Is there someone who might have the time to review this?

@behlendorf behlendorf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Skountz would you mind rebasing this again and addressing the spa_feature_t enum reordering and related abi updates while you're at it. I'll try and make time for a full review this week or next.

Comment thread include/zfeature_common.h Outdated
@Skountz
Skountz force-pushed the fix/raidz-expansion-space-accounting branch 2 times, most recently from 1b5918e to e624f6b Compare June 29, 2026 19:31
@Skountz

Skountz commented Jun 29, 2026

Copy link
Copy Markdown
Author

@Skountz would you mind rebasing this again and addressing the spa_feature_t enum reordering and related abi updates while you're at it. I'll try and make time for a full review this week or next.

The post-rebase checkABI passed, and everything is in order, I'm running the tests on a VM while the CI is doing its job just in case, but I guess we're good for the review once those are passing!

@owlshrimp

Copy link
Copy Markdown

It seems some qemu checks are failing?

@behlendorf

Copy link
Copy Markdown
Contributor

Another rebase on master should sort out the CI failures.

@behlendorf
behlendorf self-requested a review July 14, 2026 23:03
@Skountz
Skountz force-pushed the fix/raidz-expansion-space-accounting branch from e624f6b to 84c0d3f Compare July 15, 2026 07:26
Comment thread module/zcommon/zfeature_common.c
Comment thread module/zcommon/zfeature_common.c Outdated
@owlshrimp

Copy link
Copy Markdown

that issue with dedup looks......hairy. I hope it doesn't have bearing against this work, but I wouldn't bet against it.

@Skountz

Skountz commented Jul 24, 2026

Copy link
Copy Markdown
Author

that issue with dedup looks......hairy. I hope it doesn't have bearing against this work, but I wouldn't bet against it.

@owlshrimp no bearing here, they're complementary. That mixed-epoch case (a dedup entry born before expansion, extended with a post-expansion DVA) is exactly what this PR's per-block accounting handles: each DVA's size comes from its own stored ASIZE and the block's physical birth, both immutable, so a block is freed with the same deflated size it was born with. No leak.

#18826 fixes a different path, where RAIDZ uses that single birth to pick the stripe width when reading the DVA. That's an I/O safety issue, not an accounting one.

Meanwhile I'm working through behlendorf's review here: making the feature READONLY_COMPAT instead of MOS. Testing now.

After RAIDZ expansion, the deflation ratio used for space
accounting remains at the original (pre-expansion) geometry
because vdev_set_deflate_ratio() intentionally hard-codes
txg 0 to avoid inconsistently accounting for existing blocks.
This causes zpool list to underreport usable capacity.  For
example, expanding a 4-disk RAIDZ2 to 5 disks improves the
data-to-parity ratio from 2/4 to 3/5, a ~20% increase in
usable capacity per unit of raw storage, but the reported
capacity does not reflect this improvement.

Fix this in three parts:

1. Add vdev_deflate_ratio_current to vdev_t, computed using
   UINT64_MAX (current geometry).  Apply a dspace correction
   in spa_update_dspace() and spa_prop_get_config() using the
   difference between the two ratios on the FREE portion of
   each expanded vdev.  Only the free portion is corrected
   because dd_used_bytes tracks old-geometry blocks at the
   legacy ratio via bp_get_dsize_sync(); correcting allocated
   space would inflate available space beyond what can
   physically be written.  As old blocks are rewritten and
   vs_alloc shrinks, the correction automatically grows,
   converging to the full geometry correction after a complete
   rewrite.  The correction is scoped to the normal metaslab
   class because spa_rdspace derives from spa_normal_class();
   special and dedup classes contribute only allocated (not
   free) space to spa_dspace via metaslab_class_get_dalloc(),
   so they do not need a free-space correction.  The original
   txg-0 ratio is preserved in vdev_deflated_space() for
   self-consistent metaslab accounting and persistent
   DN_USED_BYTES tracking.

2. Add vdev_get_deflate_ratio(vd, birth_txg) which returns
   the correct deflation ratio for a block based on its
   physical birth txg.  For non-expanded vdevs the cached
   ratio is returned immediately; for expanded RAIDZ, the
   geometry at birth is looked up via vdev_psize_to_asize_txg()
   and the existing reflow_node_t expansion history AVL tree.
   Modify dva_get_dsize_sync() to accept a birth_txg parameter
   and bp_get_dsize_sync() to use BP_GET_PHYSICAL_BIRTH(),
   which reflects the actual on-disk allocation geometry even
   for dedup/clone blocks whose logical birth may differ.

3. Add the raidz_expansion_accounting feature flag, which
   records the txg at which per-block deflation ratio tracking
   was enabled.  Blocks born before this txg use the legacy
   fixed ratio (matching how they were originally accounted);
   blocks born at or after use the per-birth-txg ratio.  This
   prevents born/free accounting mismatches for blocks written
   between a pre-patch expansion and the first expansion with
   this code.  The feature follows the standard OpenZFS
   lifecycle: the user enables it via zpool upgrade or zpool
   set, and it is activated on the next RAIDZ expansion.  If
   enabled on an already-expanded pool, it activates
   immediately in feature_enable_sync().  The enabled_txg is
   cached in spa_raidz_expand_acct_txg for efficient runtime
   lookup.

The three changes together maintain self-consistency: metaslab
accounting uses the original ratio (alloc/free always
balance), capacity reporting is corrected at the spa level,
and per-block accounting uses stable birth-txg ratios.  No
negative overflows or positive leaks are possible.  As old
blocks are rewritten to the new geometry, the accounting
converges to exact values.

Signed-off-by: Skountz <dev@frenchbytes.fr>
@Skountz
Skountz force-pushed the fix/raidz-expansion-space-accounting branch from 84c0d3f to 3f6f238 Compare July 24, 2026 01:31
@behlendorf behlendorf added Status: Accepted Ready to integrate (reviewed, tested) and removed Status: Code Review Needed Ready for review and testing labels Jul 24, 2026
@behlendorf
behlendorf requested a review from amotin July 24, 2026 20:20
@owlshrimp

owlshrimp commented Jul 25, 2026

Copy link
Copy Markdown

that issue with dedup looks......hairy. I hope it doesn't have bearing against this work, but I wouldn't bet against it.

@owlshrimp no bearing here, they're complementary. That mixed-epoch case (a dedup entry born before expansion, extended with a post-expansion DVA) is exactly what this PR's per-block accounting handles: each DVA's size comes from its own stored ASIZE and the block's physical birth, both immutable, so a block is freed with the same deflated size it was born with. No leak.

#18826 fixes a different path, where RAIDZ uses that single birth to pick the stripe width when reading the DVA. That's an I/O safety issue, not an accounting one.

Does that mean this work might close that corner case, or would more work likely be required to fix that particular issue?

@Skountz

Skountz commented Jul 30, 2026

Copy link
Copy Markdown
Author

that issue with dedup looks......hairy. I hope it doesn't have bearing against this work, but I wouldn't bet against it.

@owlshrimp no bearing here, they're complementary. That mixed-epoch case (a dedup entry born before expansion, extended with a post-expansion DVA) is exactly what this PR's per-block accounting handles: each DVA's size comes from its own stored ASIZE and the block's physical birth, both immutable, so a block is freed with the same deflated size it was born with. No leak.
#18826 fixes a different path, where RAIDZ uses that single birth to pick the stripe width when reading the DVA. That's an I/O safety issue, not an accounting one.

Does that mean this work might close that corner case, or would more work likely be required to fix that particular issue?

No, and it's not meant to. This PR is purely about space accounting; it guarantees a block is freed with the same deflated size it was born with, per DVA. It never touches the read path, so it does nothing to prevent the wrong-geometry read that #18826 addresses. That mixed-epoch block pointer still shouldn't exist, and #18826 is what stops it from being created (and contains pools that already carry one). The two are independent and both needed; neither closes the other.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Status: Accepted Ready to integrate (reviewed, tested)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RAIDZ2 Expansion: Unexplained Space Loss & 2x Overhead Significantly inaccurate available space after multiple expansions and then a zfs rewrite

8 participants